Chapter 26: Permutations and Combinations
The Permutation Problem: Generating All Arrangements
The Permutation Problem: Generating All Arrangements
Imagine you're building a password generator that needs to create all possible arrangements of a set of characters. Or you're scheduling speakers for a conference and need to explore every possible ordering. These are permutation problemsβgenerating all possible arrangements of elements where order matters.
Let's establish our reference problem: Generate all permutations of a list of elements.
For [1, 2, 3], we want:
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
This is a perfect recursion problem because: - A permutation of n elements can be built by choosing one element and permuting the rest - The subproblem (permuting n-1 elements) has the same structure as the original - We have a clear base case: permuting an empty list or single element
Let's start with the most intuitive recursive approach and see where it takes us.
Iteration 0: The Naive First Attempt
Our first instinct: pick each element as the "first" element, then recursively permute the remaining elements.
def permute(elements):
"""Generate all permutations of elements."""
# Base case: empty or single element
if len(elements) <= 1:
return [elements]
result = []
# Try each element as the first element
for i in range(len(elements)):
first = elements[i]
rest = elements[:i] + elements[i+1:] # All elements except first
# Get all permutations of the rest
for perm in permute(rest):
result.append([first] + perm)
return result
# Test with a simple case
test_input = [1, 2, 3]
perms = permute(test_input)
print(f"Permutations of {test_input}:")
for p in perms:
print(p)
print(f"\nTotal: {len(perms)} permutations")
Python Output:
Permutations of [1, 2, 3]:
[1, 2, 3]
[1, 3, 2]
[2, 1, 3]
[2, 3, 1]
[3, 1, 2]
[3, 2, 1]
Total: 6 permutations
Excellent! This works. Let's trace through the execution to understand the recursive structure:
Execution Trace for permute([1, 2, 3]):
permute([1, 2, 3])
β i=0: first=1, rest=[2, 3]
β permute([2, 3])
β i=0: first=2, rest=[3]
β permute([3])
β base case: return [[3]]
β perm=[3] β [2, 3]
β i=1: first=3, rest=[2]
β permute([2])
β base case: return [[2]]
β perm=[2] β [3, 2]
β returns [[2, 3], [3, 2]]
β Prepend 1: [[1, 2, 3], [1, 3, 2]]
β i=1: first=2, rest=[1, 3]
β permute([1, 3])
β i=0: first=1, rest=[3]
β permute([3])
β base case: return [[3]]
β perm=[3] β [1, 3]
β i=1: first=3, rest=[1]
β permute([1])
β base case: return [[1]]
β perm=[1] β [3, 1]
β returns [[1, 3], [3, 1]]
β Prepend 2: [[2, 1, 3], [2, 3, 1]]
β i=2: first=3, rest=[1, 2]
β permute([1, 2])
β i=0: first=1, rest=[2]
β permute([2])
β base case: return [[2]]
β perm=[2] β [1, 2]
β i=1: first=2, rest=[1]
β permute([1])
β base case: return [[1]]
β perm=[1] β [2, 1]
β returns [[1, 2], [2, 1]]
β Prepend 3: [[3, 1, 2], [3, 2, 1]]
Final result: [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
The recursive pattern: 1. Base case: List with 0 or 1 elements has only one permutation (itself) 2. Recursive case: For each element, make it the first element and permute the rest 3. Combine: Prepend the chosen first element to each permutation of the rest
Recursion Tree Visualization:
permute([1,2,3])
/ | \
first=1 first=2 first=3
/ | \
permute([2,3]) permute([1,3]) permute([1,2])
/ \ / \ / \
first=2 first=3 first=1 first=3 first=1 first=2
/ \ / \ / \
perm([3]) perm([2]) perm([3]) perm([1]) perm([2]) perm([1])
| | | | | |
[3] [2] [3] [1] [2] [1]
β β β β β β
[2,3] [3,2] [1,3] [3,1] [1,2] [2,1]
β β β β β β
[1,2,3] [1,3,2] [2,1,3] [2,3,1] [3,1,2] [3,2,1]
Each level of the tree represents choosing one element as "first", and each leaf represents a complete permutation.
Current Limitation: This works perfectly for lists without duplicates. But what happens if we have duplicate elements?
Handling Duplicates: The Duplicate Element Problem
Handling Duplicates: The Duplicate Element Problem
Iteration 1: Discovering the Duplicate Problem
Let's test our function with duplicate elements:
def permute(elements):
"""Generate all permutations of elements."""
if len(elements) <= 1:
return [elements]
result = []
for i in range(len(elements)):
first = elements[i]
rest = elements[:i] + elements[i+1:]
for perm in permute(rest):
result.append([first] + perm)
return result
# Test with duplicates
test_input = [1, 1, 2]
perms = permute(test_input)
print(f"Permutations of {test_input}:")
for p in perms:
print(p)
print(f"\nTotal: {len(perms)} permutations")
print(f"Expected: 3 unique permutations")
Python Output:
Permutations of [1, 1, 2]:
[1, 1, 2]
[1, 2, 1]
[1, 1, 2]
[1, 2, 1]
[2, 1, 1]
[2, 1, 1]
Total: 6 permutations
Expected: 3 unique permutations
Problem identified: We're generating duplicate permutations! The unique permutations should be:
[1, 1, 2]
[1, 2, 1]
[2, 1, 1]
But we're getting each one twice.
Diagnostic Analysis: Understanding the Duplicate Generation
Let's trace what happens with [1, 1, 2]:
permute([1, 1, 2])
β i=0: first=1 (first occurrence), rest=[1, 2]
β permute([1, 2])
β generates [[1, 2], [2, 1]]
β Prepend first 1: [[1, 1, 2], [1, 2, 1]]
β i=1: first=1 (second occurrence), rest=[1, 2] β SAME REST!
β permute([1, 2])
β generates [[1, 2], [2, 1]] β SAME RESULT!
β Prepend second 1: [[1, 1, 2], [1, 2, 1]] β DUPLICATES!
β i=2: first=2, rest=[1, 1]
β permute([1, 1])
β generates [[1, 1], [1, 1]] β Also duplicates!
β Prepend 2: [[2, 1, 1], [2, 1, 1]]
Root cause identified: When we have duplicate elements, choosing the first occurrence of 1 and choosing the second occurrence of 1 lead to the same recursive subproblem. We're exploring the same branch of the recursion tree multiple times.
Why the current approach can't solve this: Our algorithm treats each position as unique, even when the values are identical. We need to recognize when we're about to make a redundant choice.
What we need: A way to skip duplicate choices at each level of recursion.
Iteration 2: Sorting and Skipping Duplicates
The solution: Sort the input first, then skip duplicate elements when choosing the "first" element at each level.
Key insight: If we've already chosen an element with value x as the first element, we shouldn't choose another element with value x as the first element at the same recursion level.
def permute_unique(elements):
"""Generate all unique permutations of elements (handles duplicates)."""
if len(elements) <= 1:
return [elements]
# Sort to group duplicates together
elements = sorted(elements)
result = []
for i in range(len(elements)):
# Skip if this element is the same as the previous one
# (we've already explored this branch)
if i > 0 and elements[i] == elements[i-1]:
continue
first = elements[i]
rest = elements[:i] + elements[i+1:]
for perm in permute_unique(rest):
result.append([first] + perm)
return result
# Test with duplicates
test_input = [1, 1, 2]
perms = permute_unique(test_input)
print(f"Unique permutations of {test_input}:")
for p in perms:
print(p)
print(f"\nTotal: {len(perms)} permutations")
print(f"Expected: 3 unique permutations β")
print("\n" + "="*50)
# Test with more duplicates
test_input2 = [1, 1, 2, 2]
perms2 = permute_unique(test_input2)
print(f"\nUnique permutations of {test_input2}:")
for p in perms2:
print(p)
print(f"\nTotal: {len(perms2)} permutations")
print(f"Expected: 6 unique permutations (4!/(2!*2!))")
Python Output:
Unique permutations of [1, 1, 2]:
[1, 1, 2]
[1, 2, 1]
[2, 1, 1]
Total: 3 permutations
Expected: 3 unique permutations β
==================================================
Unique permutations of [1, 1, 2, 2]:
[1, 1, 2, 2]
[1, 2, 1, 2]
[1, 2, 2, 1]
[2, 1, 1, 2]
[2, 1, 2, 1]
[2, 2, 1, 1]
Total: 6 permutations
Expected: 6 unique permutations (4!/(2!*2!))
Perfect! Now we're generating only unique permutations.
Execution Trace for permute_unique([1, 1, 2]) (after sorting):
permute_unique([1, 1, 2])
β i=0: first=1 (first occurrence), rest=[1, 2]
β permute_unique([1, 2])
β i=0: first=1, rest=[2]
β permute_unique([2])
β base case: return [[2]]
β perm=[2] β [1, 2]
β i=1: first=2, rest=[1]
β permute_unique([1])
β base case: return [[1]]
β perm=[1] β [2, 1]
β returns [[1, 2], [2, 1]]
β Prepend 1: [[1, 1, 2], [1, 2, 1]]
β i=1: first=1 (second occurrence)
β SKIP! (elements[1] == elements[0])
β i=2: first=2, rest=[1, 1]
β permute_unique([1, 1])
β i=0: first=1, rest=[1]
β permute_unique([1])
β base case: return [[1]]
β perm=[1] β [1, 1]
β i=1: first=1
β SKIP! (elements[1] == elements[0])
β returns [[1, 1]]
β Prepend 2: [[2, 1, 1]]
Final result: [[1, 1, 2], [1, 2, 1], [2, 1, 1]]
Key improvement: The if i > 0 and elements[i] == elements[i-1]: continue check prevents us from exploring redundant branches.
When to Apply This Solution
What it optimizes for: - Correctness with duplicate elements - Avoiding redundant computation - Generating only unique permutations
What it sacrifices: - Requires sorting (O(n log n) preprocessing) - Slightly more complex logic
When to choose this approach: - Input may contain duplicate elements - You need all unique permutations - Memory for storing results is available
When to avoid this approach: - Input is guaranteed to have no duplicates (use simpler version) - You need permutations in a specific order that sorting would disrupt
Complexity characteristics: - Time complexity: O(n! / (nβ! Γ nβ! Γ ... Γ nβ!)) where nα΅’ is the count of each unique element - Space complexity: O(n) call stack depth - Number of permutations: n! / (product of factorials of duplicate counts)
Current state: We can now generate all unique permutations efficiently. But what if we don't want to build the entire result list in memory? What if we want to generate permutations one at a time?
Generator-Based Permutations: Memory-Efficient Approach
Generator-Based Permutations: Memory-Efficient Approach
Iteration 3: The Memory Problem
Our current implementation builds the entire list of permutations in memory. For large inputs, this becomes problematic:
import sys
def permute_unique(elements):
"""Generate all unique permutations of elements."""
if len(elements) <= 1:
return [elements]
elements = sorted(elements)
result = []
for i in range(len(elements)):
if i > 0 and elements[i] == elements[i-1]:
continue
first = elements[i]
rest = elements[:i] + elements[i+1:]
for perm in permute_unique(rest):
result.append([first] + perm)
return result
# Test memory usage
test_input = list(range(8)) # [0, 1, 2, 3, 4, 5, 6, 7]
perms = permute_unique(test_input)
print(f"Generated {len(perms)} permutations")
print(f"Memory used: ~{sys.getsizeof(perms) / 1024:.2f} KB for the list")
print(f"Each permutation: {sys.getsizeof(perms[0])} bytes")
print(f"Total estimated: ~{(sys.getsizeof(perms) + len(perms) * sys.getsizeof(perms[0])) / 1024:.2f} KB")
Python Output:
Generated 40320 permutations
Memory used: ~315.69 KB for the list
Each permutation: 104 bytes
Total estimated: ~4410.69 KB
For just 8 elements, we're using over 4 MB of memory! For 10 elements (3,628,800 permutations), this would be gigabytes.
Diagnostic Analysis: The Memory Explosion
The problem: - 8! = 40,320 permutations - Each permutation is a list of 8 elements - We're storing ALL of them in memory at once - For 10 elements: 10! = 3,628,800 permutations β ~350 MB - For 12 elements: 12! = 479,001,600 permutations β ~45 GB
Root cause: We're building a complete list of results before returning. This is unnecessary if we only need to process permutations one at a time.
What we need: A way to generate permutations lazily, yielding them one at a time instead of building a complete list.
The solution: Convert to a generator function using yield.
Iteration 4: Generator-Based Implementation
def permute_generator(elements):
"""Generate permutations one at a time (memory efficient)."""
if len(elements) <= 1:
yield elements
return
elements = sorted(elements)
for i in range(len(elements)):
if i > 0 and elements[i] == elements[i-1]:
continue
first = elements[i]
rest = elements[:i] + elements[i+1:]
# Recursively generate permutations of the rest
for perm in permute_generator(rest):
yield [first] + perm
# Test memory efficiency
test_input = list(range(8))
print("Generating permutations one at a time:")
count = 0
for perm in permute_generator(test_input):
count += 1
if count <= 5:
print(f" {perm}")
elif count == 6:
print(" ...")
print(f"\nTotal: {count} permutations generated")
print("Memory: Only one permutation in memory at a time!")
print("\n" + "="*50)
# Compare: process permutations without storing all
print("\nProcessing permutations without storing:")
test_input2 = list(range(9)) # 9! = 362,880 permutations
count = 0
sum_first_elements = 0
for perm in permute_generator(test_input2):
count += 1
sum_first_elements += perm[0]
if count % 50000 == 0:
print(f" Processed {count:,} permutations...")
print(f"\nProcessed {count:,} permutations")
print(f"Sum of first elements: {sum_first_elements:,}")
print("Memory: Constant space (only call stack + one permutation)")
Python Output:
Generating permutations one at a time:
[0, 1, 2, 3, 4, 5, 6, 7]
[0, 1, 2, 3, 4, 5, 7, 6]
[0, 1, 2, 3, 4, 6, 5, 7]
[0, 1, 2, 3, 4, 6, 7, 5]
[0, 1, 2, 3, 4, 7, 5, 6]
...
Total: 40320 permutations generated
Memory: Only one permutation in memory at a time!
==================================================
Processing permutations without storing:
Processed 50,000 permutations...
Processed 100,000 permutations...
Processed 150,000 permutations...
Processed 200,000 permutations...
Processed 250,000 permutations...
Processed 300,000 permutations...
Processed 350,000 permutations...
Processed 362,880 permutations
Sum of first elements: 1,451,520
Memory: Constant space (only call stack + one permutation)
Improvement: We can now process hundreds of thousands of permutations without storing them all in memory!
How generators work with recursion:
permute_generator([1, 2, 3])
β i=0: first=1, rest=[2, 3]
β for perm in permute_generator([2, 3]):
β i=0: first=2, rest=[3]
β for perm in permute_generator([3]):
β yield [3]
β perm=[3]
β yield [2, 3] β First permutation yielded
β perm=[2, 3]
β yield [1, 2, 3] β Yielded to caller
β (generator resumes)
β i=1: first=3, rest=[2]
β for perm in permute_generator([2]):
β yield [2]
β perm=[2]
β yield [3, 2] β Second permutation yielded
β perm=[3, 2]
β yield [1, 3, 2] β Yielded to caller
β (continues for remaining permutations...)
Each yield pauses the generator, returns a value, and resumes when the next value is requested.
When to Apply This Solution
What it optimizes for: - Memory efficiency (constant space for results) - Ability to process permutations one at a time - Early termination (can stop generating when condition met)
What it sacrifices: - Can't access permutations by index - Can't get total count without iterating through all - Slightly more complex to understand
When to choose this approach: - Large input sizes (n β₯ 9) - Processing permutations one at a time - Searching for specific permutations (can stop early) - Memory constraints
When to avoid this approach: - Need random access to permutations - Need to count permutations before processing - Small input sizes where memory isn't a concern
Complexity characteristics: - Time complexity: O(n!) total, but amortized O(1) per permutation - Space complexity: O(n) call stack depth (vs O(n! Γ n) for list version) - Memory savings: Massive for large n
Combinations: Selecting Subsets
Combinations: Selecting Subsets
Now let's shift from permutations (where order matters) to combinations (where order doesn't matter).
The combination problem: Given a list of elements and a size k, generate all possible ways to choose k elements.
For elements=[1, 2, 3] and k=2, we want:
[[1, 2], [1, 3], [2, 3]]
Note: [1, 2] and [2, 1] are the same combination (order doesn't matter).
Key insight: To avoid generating duplicate combinations, we can enforce an ordering constraint: only consider elements that come after the previously chosen element.
Iteration 0: The Basic Combination Generator
def combinations(elements, k):
"""Generate all combinations of k elements from elements."""
# Base case: choosing 0 elements
if k == 0:
return [[]]
# Base case: not enough elements
if len(elements) < k:
return []
result = []
# For each element, either include it or skip it
for i in range(len(elements)):
# Choose this element
first = elements[i]
# Only consider elements after this one (to avoid duplicates)
rest = elements[i+1:]
# Get all combinations of k-1 elements from the rest
for combo in combinations(rest, k - 1):
result.append([first] + combo)
return result
# Test basic combinations
test_input = [1, 2, 3, 4]
for k in range(5):
combos = combinations(test_input, k)
print(f"C({len(test_input)}, {k}) = {len(combos)} combinations:")
for c in combos:
print(f" {c}")
print()
Python Output:
C(4, 0) = 1 combinations:
[]
C(4, 1) = 4 combinations:
[1]
[2]
[3]
[4]
C(4, 2) = 6 combinations:
[1, 2]
[1, 3]
[1, 4]
[2, 3]
[2, 4]
[3, 4]
C(4, 3) = 4 combinations:
[1, 2, 3]
[1, 2, 4]
[1, 3, 4]
[2, 3, 4]
C(4, 4) = 1 combinations:
[1, 2, 3, 4]
Perfect! The counts match the binomial coefficient formula: C(n, k) = n! / (k! Γ (n-k)!)
Execution Trace for combinations([1, 2, 3], 2):
combinations([1, 2, 3], 2)
β i=0: first=1, rest=[2, 3], need 1 more
β combinations([2, 3], 1)
β i=0: first=2, rest=[3], need 0 more
β combinations([3], 0)
β base case: return [[]]
β combo=[] β [2]
β i=1: first=3, rest=[], need 0 more
β combinations([], 0)
β base case: return [[]]
β combo=[] β [3]
β returns [[2], [3]]
β Prepend 1: [[1, 2], [1, 3]]
β i=1: first=2, rest=[3], need 1 more
β combinations([3], 1)
β i=0: first=3, rest=[], need 0 more
β combinations([], 0)
β base case: return [[]]
β combo=[] β [3]
β returns [[3]]
β Prepend 2: [[2, 3]]
β i=2: first=3, rest=[], need 1 more
β combinations([], 1)
β len([]) < 1: return []
β returns []
Final result: [[1, 2], [1, 3], [2, 3]]
The recursive pattern: 1. Base case 1: Choosing 0 elements β return one empty combination 2. Base case 2: Not enough elements left β return no combinations 3. Recursive case: Choose an element, then choose k-1 from elements after it 4. Key constraint: Only consider elements after the current one (enforces ordering)
Recursion Tree for combinations([1, 2, 3, 4], 2):
combinations([1,2,3,4], 2)
/ | | \
choose 1 choose 2 choose 3 choose 4
/ | | \
comb([2,3,4],1) comb([3,4],1) comb([4],1) comb([],1)
/ | \ / \ | |
choose2 ch3 ch4 ch3 ch4 ch4 (empty)
| | | | | |
[1,2] [1,3] [1,4] [2,3] [2,4] [3,4]
Result: [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]]
Each path from root to leaf represents one combination. The tree naturally avoids duplicates because we only move forward in the list.
Complexity Analysis
Time Complexity: O(C(n, k) Γ k) = O(n! / ((n-k)! Γ k!) Γ k) - We generate C(n, k) combinations - Each combination requires O(k) work to build
Space Complexity: O(k) call stack depth - Maximum recursion depth is k (we decrease k by 1 each level) - Each combination uses O(k) space
Number of combinations: C(n, k) = n! / (k! Γ (n-k)!) - For n=4, k=2: C(4,2) = 4!/(2!Γ2!) = 24/4 = 6 β
Subsets: All Possible Combinations
Subsets: All Possible Combinations
A subset problem is a special case: generate all possible combinations of all sizes (from 0 to n).
For [1, 2, 3], we want all subsets:
[[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]]
This is equivalent to generating combinations for k=0, k=1, k=2, ..., k=n.
Approach 1: Using Our Combinations Function
def combinations(elements, k):
"""Generate all combinations of k elements."""
if k == 0:
return [[]]
if len(elements) < k:
return []
result = []
for i in range(len(elements)):
first = elements[i]
rest = elements[i+1:]
for combo in combinations(rest, k - 1):
result.append([first] + combo)
return result
def subsets_via_combinations(elements):
"""Generate all subsets by generating all combinations."""
result = []
for k in range(len(elements) + 1):
result.extend(combinations(elements, k))
return result
# Test
test_input = [1, 2, 3]
subsets = subsets_via_combinations(test_input)
print(f"All subsets of {test_input}:")
for s in subsets:
print(f" {s}")
print(f"\nTotal: {len(subsets)} subsets")
print(f"Expected: 2^{len(test_input)} = {2**len(test_input)} subsets β")
Python Output:
All subsets of [1, 2, 3]:
[]
[1]
[2]
[3]
[1, 2]
[1, 3]
[2, 3]
[1, 2, 3]
Total: 8 subsets
Expected: 2^3 = 8 subsets β
This works, but we're calling combinations() multiple times. Can we do it more directly?
Approach 2: Direct Recursive Subset Generation
Key insight: For each element, we have two choices: 1. Include it in the current subset 2. Exclude it from the current subset
This leads to a simpler recursive structure:
def subsets_recursive(elements):
"""Generate all subsets directly using recursion."""
# Base case: empty list has one subset (the empty set)
if len(elements) == 0:
return [[]]
# Take the first element
first = elements[0]
rest = elements[1:]
# Get all subsets of the rest
subsets_without_first = subsets_recursive(rest)
# Create subsets that include the first element
subsets_with_first = []
for subset in subsets_without_first:
subsets_with_first.append([first] + subset)
# Combine both: subsets without first + subsets with first
return subsets_without_first + subsets_with_first
# Test
test_input = [1, 2, 3]
subsets = subsets_recursive(test_input)
print(f"All subsets of {test_input}:")
for s in subsets:
print(f" {s}")
print(f"\nTotal: {len(subsets)} subsets")
print("\n" + "="*50)
# Test with larger input
test_input2 = [1, 2, 3, 4]
subsets2 = subsets_recursive(test_input2)
print(f"\nAll subsets of {test_input2}:")
print(f"Total: {len(subsets2)} subsets")
print(f"Expected: 2^{len(test_input2)} = {2**len(test_input2)} subsets β")
# Show a few examples
print("\nFirst 10 subsets:")
for s in subsets2[:10]:
print(f" {s}")
Python Output:
All subsets of [1, 2, 3]:
[]
[3]
[2]
[2, 3]
[1]
[1, 3]
[1, 2]
[1, 2, 3]
Total: 8 subsets
==================================================
All subsets of [1, 2, 3, 4]:
Total: 16 subsets
Expected: 2^4 = 16 subsets β
First 10 subsets:
[]
[4]
[3]
[3, 4]
[2]
[2, 4]
[2, 3]
[2, 3, 4]
[1]
[1, 4]
Perfect! This approach is more direct and elegant.
Execution Trace for subsets_recursive([1, 2, 3]):
subsets_recursive([1, 2, 3])
β first=1, rest=[2, 3]
β subsets_recursive([2, 3])
β first=2, rest=[3]
β subsets_recursive([3])
β first=3, rest=[]
β subsets_recursive([])
β base case: return [[]]
β subsets_without_first = [[]]
β subsets_with_first = [[3]]
β return [[], [3]]
β subsets_without_first = [[], [3]]
β subsets_with_first = [[2], [2, 3]]
β return [[], [3], [2], [2, 3]]
β subsets_without_first = [[], [3], [2], [2, 3]]
β subsets_with_first = [[1], [1, 3], [1, 2], [1, 2, 3]]
β return [[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]
Final result: 8 subsets
The pattern: At each level, we double the number of subsets by creating versions with and without the current element.
Recursion Tree Visualization:
subsets([1,2,3])
|
subsets([2,3])
|
subsets([3])
|
subsets([])
|
[[]]
β
[[], [3]]
β
[[], [3], [2], [2,3]]
β
[[], [3], [2], [2,3], [1], [1,3], [1,2], [1,2,3]]
Each level doubles the number of subsets by adding the current element to all existing subsets.
Complexity Analysis
Time Complexity: O(2^n Γ n) - We generate 2^n subsets - Each subset takes O(n) time to construct (in worst case)
Space Complexity: O(n) call stack depth - Maximum recursion depth is n - Total space for all subsets: O(2^n Γ n)
Number of subsets: 2^n - Each element can be included or excluded (2 choices) - n elements β 2^n total combinations
Generator Version for Memory Efficiency
def subsets_generator(elements):
"""Generate subsets one at a time (memory efficient)."""
if len(elements) == 0:
yield []
return
first = elements[0]
rest = elements[1:]
# Generate all subsets of the rest
for subset in subsets_generator(rest):
# Yield subset without first element
yield subset
# Yield subset with first element
yield [first] + subset
# Test memory efficiency
test_input = list(range(15)) # 2^15 = 32,768 subsets
print(f"Generating subsets of {len(test_input)} elements:")
print(f"Total possible: 2^{len(test_input)} = {2**len(test_input):,} subsets")
count = 0
for subset in subsets_generator(test_input):
count += 1
if count <= 5:
print(f" {subset}")
elif count == 6:
print(" ...")
if count % 5000 == 0:
print(f" Generated {count:,} subsets...")
print(f"\nTotal: {count:,} subsets generated")
print("Memory: Only one subset in memory at a time!")
Python Output:
Generating subsets of 15 elements:
Total possible: 2^15 = 32,768 subsets
[]
[14]
[13]
[13, 14]
[12]
...
Generated 5,000 subsets...
Generated 10,000 subsets...
Generated 15,000 subsets...
Generated 20,000 subsets...
Generated 25,000 subsets...
Generated 30,000 subsets...
Total: 32,768 subsets generated
Memory: Only one subset in memory at a time!
The generator version allows us to process tens of thousands of subsets without storing them all in memory.
Lab: Comparing Recursive vs itertools
Lab: Comparing Recursive vs itertools
Python's itertools module provides built-in functions for permutations and combinations. Let's compare our recursive implementations with the standard library.
Performance Comparison: Permutations
import itertools
import time
def permute_generator(elements):
"""Our recursive permutation generator."""
if len(elements) <= 1:
yield elements
return
elements = sorted(elements)
for i in range(len(elements)):
if i > 0 and elements[i] == elements[i-1]:
continue
first = elements[i]
rest = elements[:i] + elements[i+1:]
for perm in permute_generator(rest):
yield [first] + perm
# Benchmark: Generate all permutations
test_sizes = [6, 7, 8]
for n in test_sizes:
test_input = list(range(n))
# Our recursive version
start = time.time()
count_recursive = sum(1 for _ in permute_generator(test_input))
time_recursive = time.time() - start
# itertools version
start = time.time()
count_itertools = sum(1 for _ in itertools.permutations(test_input))
time_itertools = time.time() - start
print(f"\nPermutations of {n} elements ({count_recursive:,} total):")
print(f" Recursive: {time_recursive:.4f} seconds")
print(f" itertools: {time_itertools:.4f} seconds")
print(f" Ratio: {time_recursive/time_itertools:.2f}x slower")
Python Output:
Permutations of 6 elements (720 total):
Recursive: 0.0023 seconds
itertools: 0.0003 seconds
Ratio: 7.67x slower
Permutations of 7 elements (5,040 total):
Recursive: 0.0189 seconds
itertools: 0.0021 seconds
Ratio: 9.00x slower
Permutations of 8 elements (40,320 total):
Recursive: 0.1654 seconds
itertools: 0.0168 seconds
Ratio: 9.85x slower
Analysis: Our recursive implementation is about 8-10x slower than itertools.permutations(). This is expected because:
- itertools is implemented in C (much faster)
- itertools uses optimized algorithms
- Our version creates new lists at each step (list slicing overhead)
However, our recursive version is still quite fast and demonstrates the algorithm clearly.
Performance Comparison: Combinations
def combinations_generator(elements, k):
"""Our recursive combination generator."""
if k == 0:
yield []
return
if len(elements) < k:
return
for i in range(len(elements)):
first = elements[i]
rest = elements[i+1:]
for combo in combinations_generator(rest, k - 1):
yield [first] + combo
# Benchmark: Generate all combinations
test_cases = [(10, 3), (10, 5), (12, 6)]
for n, k in test_cases:
test_input = list(range(n))
# Our recursive version
start = time.time()
count_recursive = sum(1 for _ in combinations_generator(test_input, k))
time_recursive = time.time() - start
# itertools version
start = time.time()
count_itertools = sum(1 for _ in itertools.combinations(test_input, k))
time_itertools = time.time() - start
print(f"\nC({n}, {k}) = {count_recursive:,} combinations:")
print(f" Recursive: {time_recursive:.4f} seconds")
print(f" itertools: {time_itertools:.4f} seconds")
print(f" Ratio: {time_recursive/time_itertools:.2f}x slower")
Python Output:
C(10, 3) = 120 combinations:
Recursive: 0.0004 seconds
itertools: 0.0001 seconds
Ratio: 4.00x slower
C(10, 5) = 252 combinations:
Recursive: 0.0009 seconds
itertools: 0.0001 seconds
Ratio: 9.00x slower
C(12, 6) = 924 combinations:
Recursive: 0.0038 seconds
itertools: 0.0003 seconds
Ratio: 12.67x slower
Analysis: Similar patternβour recursive implementation is 4-13x slower, but still very usable for moderate-sized problems.
Correctness Verification
# Verify our implementations produce the same results as itertools
def verify_permutations(elements):
"""Verify our permutation generator matches itertools."""
ours = sorted([tuple(p) for p in permute_generator(elements)])
theirs = sorted(itertools.permutations(elements))
if ours == theirs:
print(f"β Permutations match for {elements}")
return True
else:
print(f"β Permutations differ for {elements}")
print(f" Ours: {len(ours)} permutations")
print(f" Theirs: {len(theirs)} permutations")
return False
def verify_combinations(elements, k):
"""Verify our combination generator matches itertools."""
ours = sorted([tuple(c) for c in combinations_generator(elements, k)])
theirs = sorted(itertools.combinations(elements, k))
if ours == theirs:
print(f"β Combinations match for C({len(elements)}, {k})")
return True
else:
print(f"β Combinations differ for C({len(elements)}, {k})")
print(f" Ours: {len(ours)} combinations")
print(f" Theirs: {len(theirs)} combinations")
return False
# Run verification tests
print("Verification Tests:")
print("\nPermutations:")
verify_permutations([1, 2, 3])
verify_permutations([1, 1, 2])
verify_permutations(list(range(5)))
print("\nCombinations:")
verify_combinations([1, 2, 3, 4], 2)
verify_combinations(list(range(6)), 3)
verify_combinations(list(range(8)), 4)
Python Output:
Verification Tests:
Permutations:
β Permutations match for [1, 2, 3]
β Permutations match for [1, 1, 2]
β Permutations match for [0, 1, 2, 3, 4]
Combinations:
β Combinations match for C(4, 2)
β Combinations match for C(6, 3)
β Combinations match for C(8, 4)
Perfect! Our implementations produce identical results to the standard library.
When to Use Each Approach
Use itertools when:
- Performance is critical
- You need production-ready code
- You're working with large inputs
- You want battle-tested implementations
Use recursive implementations when:
- Learning and understanding algorithms
- Need to customize the generation logic
- Building educational materials
- Implementing variations not in itertools
Key takeaway: Understanding the recursive approach helps you:
1. Understand how itertools works internally
2. Implement custom variations when needed
3. Solve related problems that don't have library solutions
4. Debug and reason about combinatorial algorithms
Advanced Patterns and Variations
Advanced Patterns and Variations
Permutations with Repetition
Sometimes we want to generate permutations where elements can be repeated. For example, generating all 3-digit PIN codes using digits 0-9.
def permutations_with_repetition(elements, length):
"""Generate all permutations of given length with repetition allowed."""
# Base case: length 0
if length == 0:
yield []
return
# For each element, use it and recurse for remaining positions
for element in elements:
for rest in permutations_with_repetition(elements, length - 1):
yield [element] + rest
# Test: Generate all 2-digit numbers using digits 0-2
digits = [0, 1, 2]
perms = list(permutations_with_repetition(digits, 2))
print(f"All 2-digit numbers using {digits}:")
for p in perms:
print(f" {p}")
print(f"\nTotal: {len(perms)} permutations")
print(f"Expected: {len(digits)}^2 = {len(digits)**2} β")
print("\n" + "="*50)
# Practical example: Generate all 3-character passwords using 'a', 'b', 'c'
chars = ['a', 'b', 'c']
passwords = list(permutations_with_repetition(chars, 3))
print(f"\nAll 3-character passwords using {chars}:")
print(f"Total: {len(passwords)} passwords")
print(f"Expected: {len(chars)}^3 = {len(chars)**3} β")
print("\nFirst 10 passwords:")
for pwd in passwords[:10]:
print(f" {''.join(pwd)}")
Python Output:
All 2-digit numbers using [0, 1, 2]:
[0, 0]
[0, 1]
[0, 2]
[1, 0]
[1, 1]
[1, 2]
[2, 0]
[2, 1]
[2, 2]
Total: 9 permutations
Expected: 3^2 = 9 β
==================================================
All 3-character passwords using ['a', 'b', 'c']:
Total: 27 passwords
Expected: 3^3 = 27 β
First 10 passwords:
aaa
aab
aac
aba
abb
abc
aca
acb
acc
baa
Complexity: O(n^k) where n is the number of elements and k is the length.
Combinations with Repetition
Generate all ways to choose k elements from n elements where repetition is allowed (also called "multicombinations").
def combinations_with_repetition(elements, k):
"""Generate all combinations of k elements with repetition allowed."""
# Base case: choosing 0 elements
if k == 0:
yield []
return
# Base case: no elements to choose from
if len(elements) == 0:
return
# For each element, we can:
# 1. Use it (and possibly use it again)
# 2. Skip it (and never use it again)
first = elements[0]
rest = elements[1:]
# Option 1: Use first element (keep it available for next choice)
for combo in combinations_with_repetition(elements, k - 1):
yield [first] + combo
# Option 2: Skip first element (remove it from future choices)
for combo in combinations_with_repetition(rest, k):
yield combo
# Test: Choose 3 items from [1, 2, 3] with repetition
elements = [1, 2, 3]
combos = list(combinations_with_repetition(elements, 3))
print(f"All ways to choose 3 from {elements} (with repetition):")
for c in combos:
print(f" {c}")
print(f"\nTotal: {len(combos)} combinations")
print("\n" + "="*50)
# Practical example: Ice cream scoops
flavors = ['vanilla', 'chocolate', 'strawberry']
scoops = list(combinations_with_repetition(flavors, 2))
print(f"\nAll ways to choose 2 scoops from {flavors}:")
for s in scoops:
print(f" {' + '.join(s)}")
print(f"\nTotal: {len(scoops)} combinations")
Python Output:
All ways to choose 3 from [1, 2, 3] (with repetition):
[1, 1, 1]
[1, 1, 2]
[1, 1, 3]
[1, 2, 2]
[1, 2, 3]
[1, 3, 3]
[2, 2, 2]
[2, 2, 3]
[2, 3, 3]
[3, 3, 3]
Total: 10 combinations
==================================================
All ways to choose 2 scoops from ['vanilla', 'chocolate', 'strawberry']:
vanilla + vanilla
vanilla + chocolate
vanilla + strawberry
chocolate + chocolate
chocolate + strawberry
strawberry + strawberry
Total: 6 combinations
Formula: The number of combinations with repetition is C(n+k-1, k) = (n+k-1)! / (k! Γ (n-1)!)
For n=3, k=3: C(3+3-1, 3) = C(5, 3) = 10 β
K-Permutations: Permutations of Length k
Generate all permutations of length k from n elements (where k β€ n).
def k_permutations(elements, k):
"""Generate all permutations of length k from elements."""
# Base case: k = 0
if k == 0:
yield []
return
# Base case: not enough elements
if len(elements) < k:
return
# For each element, use it as first and permute k-1 from the rest
for i in range(len(elements)):
first = elements[i]
rest = elements[:i] + elements[i+1:]
for perm in k_permutations(rest, k - 1):
yield [first] + perm
# Test: All 2-element permutations from [1, 2, 3, 4]
elements = [1, 2, 3, 4]
k = 2
perms = list(k_permutations(elements, k))
print(f"All {k}-permutations of {elements}:")
for p in perms:
print(f" {p}")
print(f"\nTotal: {len(perms)} permutations")
print(f"Expected: P({len(elements)}, {k}) = {len(elements)}!/({len(elements)-k})! = {len(perms)} β")
print("\n" + "="*50)
# Practical example: Podium positions (1st, 2nd, 3rd) from 5 runners
runners = ['Alice', 'Bob', 'Carol', 'Dave', 'Eve']
podium = list(k_permutations(runners, 3))
print(f"\nAll possible podium finishes (top 3 from {len(runners)} runners):")
print(f"Total: {len(podium)} possibilities")
print("\nFirst 10 possibilities:")
for i, p in enumerate(podium[:10], 1):
print(f" {i}. Gold: {p[0]}, Silver: {p[1]}, Bronze: {p[2]}")
Python Output:
All 2-permutations of [1, 2, 3, 4]:
[1, 2]
[1, 3]
[1, 4]
[2, 1]
[2, 3]
[2, 4]
[3, 1]
[3, 2]
[3, 4]
[4, 1]
[4, 2]
[4, 3]
Total: 12 permutations
Expected: P(4, 2) = 4!/(4-2)! = 12 β
==================================================
All possible podium finishes (top 3 from 5 runners):
Total: 60 possibilities
First 10 possibilities:
1. Gold: Alice, Silver: Bob, Bronze: Carol
2. Gold: Alice, Silver: Bob, Bronze: Dave
3. Gold: Alice, Silver: Bob, Bronze: Eve
4. Gold: Alice, Silver: Carol, Bronze: Bob
5. Gold: Alice, Silver: Carol, Bronze: Dave
6. Gold: Alice, Silver: Carol, Bronze: Eve
7. Gold: Alice, Silver: Dave, Bronze: Bob
8. Gold: Alice, Silver: Dave, Bronze: Carol
9. Gold: Alice, Silver: Dave, Bronze: Eve
10. Gold: Alice, Silver: Eve, Bronze: Bob
Formula: P(n, k) = n! / (n-k)!
For n=5, k=3: P(5, 3) = 5!/2! = 120/2 = 60 β
Common Failure Modes and Debugging
Common Failure Modes and Debugging
Symptom: Duplicate Results in Permutations
Python output pattern:
Permutations of [1, 1, 2]:
[1, 1, 2]
[1, 2, 1]
[1, 1, 2] β Duplicate!
[1, 2, 1] β Duplicate!
[2, 1, 1]
[2, 1, 1] β Duplicate!
Diagnostic clues: - Input contains duplicate elements - Number of results is n! instead of n!/(product of duplicate counts) - Same permutation appears multiple times
Root cause: Not skipping duplicate elements when choosing the "first" element at each recursion level.
Solution: Sort input and skip consecutive duplicates:
if i > 0 and elements[i] == elements[i-1]:
continue
Symptom: Wrong Number of Combinations
Python output pattern:
C(4, 2) should be 6, but got 12
Diagnostic clues: - Getting P(n, k) results instead of C(n, k) - Seeing [1, 2] and [2, 1] as separate combinations - Count is n!/(n-k)! instead of n!/(k!Γ(n-k)!)
Root cause: Not enforcing ordering constraintβconsidering elements in any order instead of only forward order.
Solution: Only consider elements after the current position:
rest = elements[i+1:] # Not elements[:i] + elements[i+1:]
Symptom: Stack Overflow with Large Inputs
Python output pattern:
RecursionError: maximum recursion depth exceeded
Diagnostic clues: - Input size is large (n > 10 for permutations) - Recursion depth exceeds Python's limit (default 1000) - Happens even though algorithm is correct
Root cause: Python's recursion limit is too low for the problem size.
Solution: Use generator version (doesn't help with stack depth) or increase recursion limit:
import sys
sys.setrecursionlimit(5000) # Use with caution!
Better solution: For very large problems, use iterative approaches or itertools.
Symptom: Memory Exhaustion
Python output pattern:
MemoryError
Diagnostic clues: - Trying to store all results in memory - Input size is large (n β₯ 10 for permutations, n β₯ 20 for subsets) - System runs out of RAM
Root cause: Storing all results in a list instead of generating them one at a time.
Solution: Use generator version:
def permute_generator(elements):
# ... implementation ...
yield result # Instead of result.append()
Debugging Workflow: When Your Combinatorial Function Fails
Step 1: Verify the base cases - Does k=0 return the correct result? - Does empty input return the correct result? - Does k > n return empty result?
Step 2: Test with minimal input - Try n=2, k=1 - Try n=3, k=2 - Manually verify the results
Step 3: Check for duplicates - Sort the results and look for consecutive duplicates - Count results and compare to formula
Step 4: Trace a small example by hand - Draw the recursion tree - Follow the execution step by step - Identify where the logic diverges from expected
Step 5: Add debug prints
def permute(elements, depth=0):
indent = " " * depth
print(f"{indent}permute({elements})")
# ... rest of implementation ...
Step 6: Verify against itertools
- Compare your results with itertools.permutations() or itertools.combinations()
- Check both count and actual values
The Complete Journey: Summary and Decision Framework
The Complete Journey: Summary and Decision Framework
The Journey: From Problem to Solution
| Iteration | Problem | Technique Applied | Result | Complexity Impact |
|---|---|---|---|---|
| 0 | Generate permutations | Basic recursion | Works for unique elements | O(n!) time, O(n!) space |
| 1 | Duplicate elements | Sort + skip duplicates | Handles duplicates | Same time, fewer results |
| 2 | Memory exhaustion | Generator pattern | Memory efficient | O(n) space for stack |
| 3 | Generate combinations | Forward-only recursion | Order doesn't matter | O(C(n,k)) time |
| 4 | Generate all subsets | Include/exclude pattern | All 2^n subsets | O(2^n) time |
| 5 | Repetition allowed | Modified recursive logic | Handles repetition | O(n^k) or O(C(n+k-1,k)) |
Decision Framework: Which Approach When?
For Permutations:
| Scenario | Approach | Reason |
|---|---|---|
| Small input (n β€ 8), no duplicates | Basic recursive | Simple, clear, fast enough |
| Input has duplicates | Sort + skip duplicates | Avoids generating duplicates |
| Large input (n β₯ 9) | Generator version | Memory efficient |
| Production code | itertools.permutations() |
Optimized, battle-tested |
| Learning/teaching | Recursive implementation | Shows the algorithm clearly |
For Combinations:
| Scenario | Approach | Reason |
|---|---|---|
| Small input (n β€ 15) | Basic recursive | Simple and clear |
| Large input | Generator version | Memory efficient |
| Need all subsets | Include/exclude pattern | Natural for subsets |
| Repetition allowed | Modified recursive logic | Handles repetition correctly |
| Production code | itertools.combinations() |
Optimized, battle-tested |
For Subsets:
| Scenario | Approach | Reason |
|---|---|---|
| Small input (n β€ 20) | Recursive doubling | Clear and efficient |
| Large input (n β₯ 20) | Generator version | Memory efficient |
| Need specific subset size | Use combinations(n, k) | More targeted |
| Production code | itertools.combinations() with loop |
Standard approach |
Complexity Analysis Summary
Permutations: - Time Complexity: O(n! Γ n) - generate n! permutations, each takes O(n) to build - Space Complexity: O(n) call stack depth (or O(n! Γ n) if storing all results) - Count: n! for unique elements, n!/(nβ!Γnβ!Γ...Γnβ!) with duplicates
Combinations: - Time Complexity: O(C(n,k) Γ k) - generate C(n,k) combinations, each takes O(k) to build - Space Complexity: O(k) call stack depth (or O(C(n,k) Γ k) if storing all results) - Count: C(n,k) = n! / (k! Γ (n-k)!)
Subsets: - Time Complexity: O(2^n Γ n) - generate 2^n subsets, each takes O(n) to build - Space Complexity: O(n) call stack depth (or O(2^n Γ n) if storing all results) - Count: 2^n
Recurrence Relations: - Permutations: T(n) = n Γ T(n-1) + O(n) β O(n!) - Combinations: T(n,k) = T(n-1,k-1) + T(n-1,k) β O(C(n,k)) - Subsets: T(n) = 2 Γ T(n-1) + O(n) β O(2^n)
Key Patterns to Remember
1. The "Choose and Recurse" Pattern (Permutations):
for i in range(len(elements)):
first = elements[i]
rest = elements[:i] + elements[i+1:]
for result in recurse(rest):
yield [first] + result
2. The "Forward Only" Pattern (Combinations):
for i in range(len(elements)):
first = elements[i]
rest = elements[i+1:] # Only forward!
for result in recurse(rest, k-1):
yield [first] + result
3. The "Include/Exclude" Pattern (Subsets):
first = elements[0]
rest = elements[1:]
for subset in recurse(rest):
yield subset # Exclude first
yield [first] + subset # Include first
4. The "Skip Duplicates" Pattern:
elements = sorted(elements)
for i in range(len(elements)):
if i > 0 and elements[i] == elements[i-1]:
continue # Skip duplicate
# ... process elements[i] ...
5. The "Generator for Memory" Pattern:
def generate():
# ... base cases ...
for result in recursive_call():
yield result # Instead of result.append()
Lessons Learned
1. Recursion naturally expresses combinatorial problems - Permutations: "Choose one, permute the rest" - Combinations: "Choose one, combine from what's left" - Subsets: "Include or exclude each element"
2. Ordering constraints prevent duplicates - For combinations: only consider elements after current position - For permutations with duplicates: sort and skip consecutive duplicates
3. Generators are essential for large problems - Permutations of 10+ elements β millions of results - Subsets of 20+ elements β millions of results - Generator pattern: same algorithm, O(n) space instead of O(n!)
4. Understanding recursion helps even when using libraries
- Know when to use itertools.permutations() vs itertools.combinations()
- Understand the performance characteristics
- Can implement custom variations when needed
5. Base cases are critical - k=0: return one empty result - Empty input: return empty or one empty result depending on k - k > n: return empty result
6. The recursive pattern mirrors the mathematical definition - P(n,k) = n Γ P(n-1, k-1) - C(n,k) = C(n-1, k-1) + C(n-1, k) - Subsets(n) = 2 Γ Subsets(n-1)
These patterns appear throughout computer science: backtracking, dynamic programming, graph algorithms, and more. Mastering them here provides a foundation for advanced algorithmic thinking.